package edu.hawaii.halealohacli.jcev.command;

import java.text.DecimalFormat;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.Hashtable;
import java.util.List;
import java.util.Locale;
import java.util.TimeZone;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.xml.datatype.XMLGregorianCalendar;
import org.wattdepot.client.WattDepotClient;
import org.wattdepot.resource.source.jaxb.Source;
import org.wattdepot.util.tstamp.Tstamp;

/**
 * Returns a list sorted from least to most energy consumed between the start and end dates. Dates
 * must be in (yyyy-mm-dd) format. Syntax: rank-towers [start] [end].
 * 
 * @author Jordan Takayama
 */
public class RankTowers implements Command {
  /**
   * The WattDepot client that is used to request data from the server.
   */
  private WattDepotClient client;
  /**
   * The list of Sources which represent Hale Aloha towers.
   */
  private List<Source> towerList;
  /**
   * The start date for the energy consumption data request, in yyyy-MM-dd format.
   */
  private String startDate;
  /**
   * The end date for the energy consumption data request, in yyyy-MM-dd format.
   */
  private String endDate;
  /**
   * The Timestamp that represents the start time for the energy consumption data request.
   */
  private XMLGregorianCalendar startTimestamp;
  /**
   * The Timestamp that represents the end time for the energy consumption data request.
   */
  private XMLGregorianCalendar[] endTimestamps;
  /**
   * The energy consumption totals for each tower, used to retrieve tower Sources from energyTable.
   * Values are assumed to be in kilowatt-hours.
   */
  private double[] energyRank;
  /**
   * The hash table that maps energy consumption values to tower Sources.
   */
  private Hashtable<Double, List<Source>> energyTable;
  /**
   * Formats the contents of Dates into yyyy-MM-dd format.
   */
  private final SimpleDateFormat FORMAT = new SimpleDateFormat("yyyy-MM-dd", Locale.getDefault());
  /**
   * Truncates decimals into whole numbers. Used to truncate measurements of energy consumption.
   */
  private final DecimalFormat ENERGY_FORMAT = new DecimalFormat("####");

  /**
   * Verifies the arguments, executes the command, and prints the results to the screen.
   * 
   * @param args Space delimited string of arguments (i.e. "arg1 arg2 arg3").
   * @param client The WattDepot client that will be performing the queries.
   * @throws Exception If an exception was thrown by the command (i.e. bad arguments) or any of its
   * function calls.
   */
  @Override
  public void execute(String args, WattDepotClient client) throws Exception {
    if (args.length() > 0) {
      String[] splitArgs = args.split("[ ]");
      if (splitArgs.length < 2) {
        throw new Exception("Bad input: Less than 2 arguments.\n"
            + "Correct syntax: rank-towers [start] [end]");
      }
      else if (splitArgs.length > 2) {
        throw new Exception("Bad input: More than 2 arguments.\n"
            + "Correct syntax: rank-towers [start] [end]");
      }
      else if (splitArgs.length == 2) {
        // A series of input checks for date ranges and date overlaps.
        if (isDateFormatCorrect(splitArgs[0])) {
          if (isDateFormatCorrect(splitArgs[1])) {
            if (inCalendarRanges(splitArgs[0])) {
              if (inCalendarRanges(splitArgs[1])) {
                if (noDateConflict(splitArgs[0], splitArgs[1])) {
                  List<Source> sources = client.getSources();
                  List<Source> towers = new ArrayList<Source>();
                  // Assume the name of a Tower contains characters a-z and A-Z, 
                  // with no dashes or whitespace.
                  Pattern towerPattern = Pattern.compile("^[a-zA-Z]+$");
                  Matcher matchTower = null;
                  // Identify the source names of towers and add them to the List.
                  for (Source source : sources) {
                    matchTower = towerPattern.matcher(source.getName());
                    if (matchTower.matches()) {
                      towers.add(source);
                    }
                  }
                  RankTowers towerList = new RankTowers();
                  towerList = towerList.setAll(towers, splitArgs[0], splitArgs[1], client);
                  System.out.println(towerList.toString());
                }
                else {
                  throw new Exception("Bad input: dates " + splitArgs[0] + " " + splitArgs[1] + 
                      " overlap or are beyond the current date.");
                }
              }
              else {
                throw new Exception("Bad input: end date " + splitArgs[1]
                    + " is not within calendar ranges.");
              } 
            }
            else {
              throw new Exception("Bad input: start date " + splitArgs[0]
                  + " is not within calendar ranges.");
            }
          }
          else {
            throw new Exception("Bad input: end date " + splitArgs[1]
                + " not in yyyy-mm-dd format.");
          }
        }
        else {
          throw new Exception("Bad input: start date " + splitArgs[0]
              + " not in yyyy-mm-dd format.");
        }
      }
    }
    else {
      throw new Exception("Bad input: No arguments given.");
    }
  }

  /**
   * Creates a RankTowers object with all fields uninitialized. A RankTowers object queries a
   * WattDepotClient to rank the Hale Aloha towers by energy consumption from a start date to an end
   * date.
   */
  public RankTowers() {
    // Fields should be initialized using setAll.
  }

  /**
   * Create an object which ranks the Hale Aloha towers in ascending order by energy consumption for
   * the time interval specified by the start and end dates.
   * 
   * @param towerList The list of towers in the Hale Aloha residences.
   * @param beginDate The date at which data collection is to begin.
   * @param endDate The date at which data collection is to end.
   * @param client The WattDepotClient which is being used to collect the data.
   * @return This altered RankTowers object.
   * @throws Exception A generic Exception, or the NotAuthorizedException,
   * ResourceNotFoundException, BadXmlException, or MiscClientException thrown by the
   * WattDepotClient.
   */
  public RankTowers setAll(List<Source> towerList, String beginDate, String endDate,
      WattDepotClient client) throws Exception {
    XMLGregorianCalendar[] sourceTimestamps = null;

    this.setClient(client);
    this.setTowerList(towerList);
    this.setStartDate(beginDate);
    this.setEndDate(endDate);

    sourceTimestamps = new XMLGregorianCalendar[towerList.size()];
    // Even if the start date is the same as the end date, start at midnight.
    this.setStartTimestamp(generateTimestamp(beginDate, "T00:00:00.000", TimeZone.getDefault()));
    if (endDate.equals(FORMAT.format(new Date()))) {
      // If the end date is the same as today, fill endTimestamps with the latest
      // available SensorData timestamp for each source.
      int position = 0;
      for (Source source : towerList) {
        String sourceName = source.getName();
        sourceTimestamps[position] = client.getLatestSensorData(sourceName).getTimestamp();
        position++;
      }
      this.setEndTimestamps(sourceTimestamps);
    }
    else {
      // Otherwise, set all of the sources' timestamps to the end of the specified day.
      for (int i = 0; i < sourceTimestamps.length; i++) {
        sourceTimestamps[i] = generateTimestamp(endDate, "T23:59:59.999", TimeZone.getDefault());
      }
      this.setEndTimestamps(sourceTimestamps);
    }
    double[] tempEnergyRank = new double[towerList.size()];
    Hashtable<Double, List<Source>> tempTable = new Hashtable<Double, List<Source>>();
    setEnergyTable(tempTable);
    int position = 0;
    double energy = 0.0;
    for (Source source : towerList) {
      // If the interval is greater than zero, this code will erroneously request data
      // past the range of available data if it requests data for the current day within
      // [interval] minutes of midnight, where "interval" is the length of the sampling interval.
      energy = client
          .getEnergyConsumed(source.getName(), startTimestamp, sourceTimestamps[position], 0);
      if (energy < 0) {
        throw new Exception("Client returned energy consumption < 0. Counter may have been reset.\n"
            + "See http://code.google.com/p/wattdepot/wiki/PropertyDictionary"
            +  "#Sensor_Data_Properties\nfor further information.");
      }
      else {
        // Format energy consumed as a cleaner value.
        energy = Double.parseDouble(ENERGY_FORMAT.format(energy / 1000));
        tempEnergyRank[position] = energy;
        List<Source> sourceList = new ArrayList<Source>();
        sourceList.add(source);
        putInEnergyTable(tempEnergyRank[position], sourceList);
      }
      position++;
    }
    tempEnergyRank = sortAscending(tempEnergyRank);
    this.setEnergyRank(tempEnergyRank);
    return this;
  }

  /**
   * Returns the WattDepot client used by this RankTowers object.
   * 
   * @return The WattDepot client used by this RankTowers object.
   */
  public WattDepotClient getClient() {
    return this.client;
  }

  /**
   * Sets the WattDepot client used by this RankTowers object.
   * 
   * @param client The WattDepot client used by this RankTowers object.
   */
  private void setClient(WattDepotClient client) {
    this.client = client;
  }

  /**
   * Returns this RankTowers object's list of tower' Sources.
   * 
   * @return The list of towers' Sources used by this RankTowers object.
   */
  public List<Source> getTowerList() {
    return towerList;
  }

  /**
   * Sets this RankTowers object's list of tower' Sources.
   * 
   * @param towerList The list of towers' Sources used by this RankTowers object.
   */
  private void setTowerList(List<Source> towerList) {
    this.towerList = towerList;
  }

  /**
   * Sets the String representing the starting date.
   * 
   * @return The String representing the starting date. Expected format: yyyy-MM-dd.
   */
  public String getStartDate() {
    return startDate;
  }

  /**
   * Returns the String representing the starting date.
   * 
   * @param startDate The String representing the starting date. Expected format: yyyy-MM-dd.
   */
  private void setStartDate(String startDate) {
    this.startDate = startDate;
  }

  /**
   * Sets the String representing the ending date.
   * 
   * @return The String representing the ending date. Expected format: yyyy-mm-dd.
   */
  public String getEndDate() {
    return endDate;
  }

  /**
   * Returns the String representing the ending date.
   * 
   * @param endDate The String representing the ending date. Expected format: yyyy-mm-dd.
   */
  private void setEndDate(String endDate) {
    this.endDate = endDate;
  }

  /**
   * Returns a copy of the timestamp that represents the start time for the energy data request.
   * 
   * @return The start time used to request energy consumption data.
   */
  public XMLGregorianCalendar getStartTimestamp() {
    return (XMLGregorianCalendar) this.startTimestamp.clone();
  }

  /**
   * Sets the timestamp that represents the start time for the energy data request.
   * 
   * @param startTimestamp The start time used to request energy consumption data.
   */
  private void setStartTimestamp(XMLGregorianCalendar startTimestamp) {
    this.startTimestamp = startTimestamp;
  }

  /**
   * Returns a copy of the timestamps that represent the cutoff times for the energy data request.
   * 
   * @return The end times used to request energy consumption data for each tower.
   */
  public XMLGregorianCalendar[] getEndTimestamps() {
    return this.endTimestamps.clone();
  }

  /**
   * Sets the timestamps that represents the cutoff times for the energy data request.
   * 
   * @param endTimestamps The end times used to request energy consumption data for each tower.
   */
  private void setEndTimestamps(XMLGregorianCalendar[] endTimestamps) {
    this.endTimestamps = endTimestamps;
  }

  /**
   * Returns a copy of the array of energy consumption values.
   * 
   * @return A copy of the array of energy consumption values.
   */
  public double[] getEnergyRank() {
    return this.energyRank.clone();
  }

  /**
   * Sets the array of energy consumption values. Assumes the array is sorted beforehand.
   * 
   * @param energyRank The array of energy consumption values.
   */
  private void setEnergyRank(double[] energyRank) {
    this.energyRank = energyRank;
  }

  /**
   * Returns a copy of the Hashtable that maps energy consumption to tower Sources.
   * 
   * @return A copy of the Hashtable that maps energy consumption to tower Sources.
   */
  public Hashtable<Double, List<Source>> getEnergyTable() {
    Hashtable<Double, List<Source>> returnTable = new Hashtable<Double, List<Source>>();
    returnTable.putAll(this.energyTable);
    return returnTable;
  }

  /**
   * Sets the Hashtable that maps energy consumption to tower Sources.
   * 
   * @param energyTable The Hashtable that maps energy consumption to tower Sources.
   */
  private void setEnergyTable(Hashtable<Double, List<Source>> energyTable) {
    this.energyTable = energyTable;
  }

  /**
   * Puts Sources into the hash table of Sources, mapped to energy consumption values.
   * 
   * @param key The energy consumed by the Source.
   * @param value The Source being added.
   */
  private void putInEnergyTable(Double key, List<Source> value) {
    // If no items with the same key are in the table, add the value.
    if (this.energyTable.get(key) == null) {
      this.energyTable.put(key, value);
    }
    // Otherwise, add all the Sources in the list to the existing list.
    else {
      List<Source> tempList = this.energyTable.remove(key);
      for (Source source : value) {
        tempList.add(source);
      }
      this.energyTable.put(key, tempList);
    }
  }

  /**
   * Generates the GMT offset in hours, returned as a string in the form +hh:mm or -hh:mm.
   * 
   * @param zone A time zone.
   * @return The GMT offset of the specified time zone in the form +hh:mm or -hh:mm, where h is
   * hours and m is minutes.
   */
  private String gmtOffsetString(TimeZone zone) {
    String result = "";
    String minutesOffset = ":00";
    // Convert to hours: 1000 milliseconds/second * 60 seconds/minute * 60 minutes/hour = 1 hour.
    int offsetHours = zone.getRawOffset() / (1000 * 60 * 60);
    String offsetString = Integer.toString(offsetHours);
    if (Math.abs(offsetHours) < 10) {
      if (offsetHours < 0) {
        // e.g., "-8" becomes "-08:00"
        result =
            offsetString.substring(0, 1) + "0"
                + offsetString.substring(1, Integer.toString(offsetHours).length()) + minutesOffset;
      }
      else {
        // e.g., "7" becomes "+07:00"
        result = "+0" + offsetString + minutesOffset;
      }
    }
    else {
      if (offsetHours >= 0) {
        // e.g., "10" becomes "+10:00"
        result = "+" + offsetHours + minutesOffset;
      }
      else if (offsetHours < 0) {
        // e.g., "-10" becomes "-10:00"
        result = offsetHours + minutesOffset;
      }
    }
    return result;
  }

  /**
   * Returns an XMLGregorianCalendar with its time set to the parameter of "time".
   * 
   * @param date The date, in format yyyy-mm-dd.
   * @param time The time, in format THH:mm:ss.SSS.
   * @param zone The user's specified time zone.
   * @return An XMLGregorianCalendar representing the desired timestamp.
   * @throws Exception If the date is not in the format yyyy-mm-dd, or if an exception was thrown by
   * this method or any of its function calls.
   */
  private XMLGregorianCalendar generateTimestamp(String date, String time, TimeZone zone)
      throws Exception {
    if (isDateFormatCorrect(date)) {
      return Tstamp.makeTimestamp(date + time + gmtOffsetString(zone));
    }
    else {
      throw new Exception("Incorrectly formatted date. Correct date format: yyyy-mm-dd");
    }
  }

  /**
   * Checks that a date is in the format yyyy-mm-dd.
   * 
   * @param date A string representing a date.
   * @return Whether or not the string is in yyyy-mm-dd format.
   */
  private boolean isDateFormatCorrect(String date) {
    Pattern datePattern = Pattern.compile("^[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]+$");
    Matcher matchDate = datePattern.matcher(date);
    return matchDate.matches();
  }

  /**
   * Checks that the month and day of a String representing a date are within calendar ranges.
   * 
   * @param checkedDate The date being checked, expected to be in yyyy-MM-dd format.
   * @return True if the date is within the calendar ranges, false otherwise.
   */
  private boolean inCalendarRanges(String checkedDate) {
    boolean inRange = false;
    int year = Integer.parseInt(checkedDate.substring(0, 4));
    int month = Integer.parseInt(checkedDate.substring(5, 7));
    int day = Integer.parseInt(checkedDate.substring(8, 10));
    if (month > 0 && month <= 12 && day > 0) {
      // Special cases: February and leap years
      if (month == 2) {
        // "The Gregorian leap year rule is: Every year that is exactly divisible by four is a leap
        // year, except for years that are exactly divisible by 100; the centurial years that are
        // exactly divisible by 400 are still leap years."
        // Source: U.S. Naval Observatory, <http://www.usno.navy.mil/USNO/astronomical-applications/
        // astronomical-information-center/calendars/>, retrieved November 13, 2011.
        if (year % 4 == 0 && (!(year % 100 == 0) || year % 400 == 0)) {
          if (day <= 29) {
            inRange = true;
          }
        }
        else {
          if (day <= 28) {
            inRange = true;
          }
        }
      }
      else {
        if ((month == 1 || month == 3 || month == 5 || month == 7 || month == 8 || month == 10 || 
            month == 12) && day <= 31) {
          inRange = true;
        }
        else if ((month == 4 || month == 6 || month == 9 || month == 11) && day <= 30) {
          inRange = true;
        }
      }
    }
    return inRange;
  }

  /**
   * Checks if a starting date is less than an ending date, and that both dates are less than or
   * equal to the current date.
   * 
   * @param startDate The starting date (expected format: yyyy-MM-dd)
   * @param endDate The ending date (expected format: yyyy-MM-dd)
   * @return True if the date range is acceptable, or false if not.
   */
  private boolean noDateConflict(String startDate, String endDate) {
    boolean datesVerified = false;
    Date currentDate = new Date();
    SimpleDateFormat rangeChecker = new SimpleDateFormat("yyyy-MM-dd", Locale.getDefault());
    String formattedCurrentDate = rangeChecker.format(currentDate);
    int year = Integer.parseInt(formattedCurrentDate.substring(0, 4));
    int month = Integer.parseInt(formattedCurrentDate.substring(5, 7));
    int day = Integer.parseInt(formattedCurrentDate.substring(8, 10));
    int startYear = Integer.parseInt(startDate.substring(0, 4));
    int startMonth = Integer.parseInt(startDate.substring(5, 7));
    int startDay = Integer.parseInt(startDate.substring(8, 10));
    int endYear = Integer.parseInt(endDate.substring(0, 4));
    int endMonth = Integer.parseInt(endDate.substring(5, 7));
    int endDay = Integer.parseInt(endDate.substring(8, 10));
    // startYear cannot be greater than endYear, endYear cannot be greater than year.
    if (startYear <= endYear && endYear <= year) {
      if (endYear == year && endMonth <= month) {
        if (endDay <= day && startYear == endYear) {
          // startMonth cannot be greater than endMonth.
          if (startMonth == endMonth) {
            // startDay cannot be greater than endDay.
            // Towers can be ranked by the data for just the current day.
            if (startDay <= endDay) {
              datesVerified = true;
            }
          }
          // If the months are different, the days do not matter.
          else if (startMonth < endMonth) {
            datesVerified = true;
          }
        }
        // If the years are different, days and months do not matter.
        else if (startYear < endYear) {
          datesVerified = true;
        }
      }
      else if (startYear == endYear) {
        // startMonth cannot be greater than endMonth.
        if (startMonth == endMonth) {
          // startDay cannot be greater than endDay.
          if (startDay <= endDay) {
            datesVerified = true;
          }
        }
        // If the months are different, the days do not matter.
        else if (startMonth < endMonth) {
          datesVerified = true;
        }
      }
      // If the years are different, days and months do not matter.
      else if (startYear < endYear) {
        datesVerified = true;
      }
    }
    return datesVerified;
  }

  /**
   * Performs a standard insertion sort of an array of doubles. Based on: "Insertion Sort",
   * <http://www.algolist.net/Algorithms/Sorting/Insertion_sort>, retrieved November 3, 2011.
   * 
   * @param array An array of doubles.
   * @return The array sorted in ascending order.
   */
  private double[] sortAscending(double[] array) {
    double swap = 0;
    for (int i = 1; i < array.length; i++) {
      swap = array[i];
      int previous = i;
      while ((previous > 0) && array[previous - 1] > swap) {
        array[previous] = array[previous - 1];
        previous--;
      }
      array[previous] = swap;
    }
    return array;
  }

  /**
   * Returns the contents of a RankTowers object as a string.
   * 
   * @return The contents of a RankTowers object as a string.
   */
  @Override
  public String toString() {
    Hashtable<Double, List<Source>> printSources = this.getEnergyTable();
    double[] printEnergy = this.getEnergyRank();
    StringBuilder result = new StringBuilder();
    StringBuilder spaces = new StringBuilder();

    result.append("For the interval " + this.getStartDate() + " to " + this.getEndDate()
        + ", energy consumption by tower was:\n");
    for (int i = 0; i < printEnergy.length; i++) {
      if (printSources.get(printEnergy[i]) != null) {
        List<Source> tempSources = printSources.remove(printEnergy[i]);
        for (Source source : tempSources) {
          spaces.append(source.getName());
          // Add spaces between name and energy consumption
          for (int j = 0; j < (10 - source.getName().length()); j++) {
            spaces.append(" ");
          }
          // Format energy consumed as a cleaner value. Assumes the values are already in kWh.
          result.append(spaces.toString() + ENERGY_FORMAT.format(printEnergy[i])
              + " kWh\n");
          spaces = new StringBuilder();
        }
      }
    }
    return result.toString();
  }

  /**
   * Returns the string to be printed by the help command. Format:
   * "command-name [args1] [args2] .... \nDescription"
   * 
   * @return The string to be printed by the help command.
   */
  @Override
  public String getHelpString() {
    return "rank-towers [start] [end]\n"
        + "Returns a list in sorted order from least to most energy consumed between the [start] "
        + "and [end] date (yyyy-mm-dd).";
  }
}
